feat(composer): add rich text editor with formatting and mentions#310
Draft
feat(composer): add rich text editor with formatting and mentions#310
Conversation
wesbillman
approved these changes
Apr 14, 2026
Collaborator
|
@codex review please |
…TipTap integration Replace plain textarea message composer with TipTap-based rich text editor. - Add FormattingToolbar with bold, italic, strikethrough, code, and link controls - Add Toggle UI primitive (shared component) - Integrate image uploads as context chips with autocomplete suggestions (ImageRefAutocomplete, imageRefExtension, useImageRefSuggestions) - New useRichTextEditor hook encapsulating TipTap setup - New ComposerAttachments component for uploaded media display - Remove legacy ComposerMentionOverlay (replaced by TipTap mention/ref system) - Update MessageComposer and MessageComposerToolbar for new editor - Keyboard shortcuts: ⌘B bold, ⌘I italic, ⌘K link, ⌘S sidebar toggle - Update ChannelPane, sidebar, globals.css for layout/style adjustments - Update dependencies (Cargo.toml, package.json, pnpm-lock.yaml)
Move expanded formatting options (B, I, S, Code, Link, Lists, Quote) to sit inline in the bottom toolbar right after the Aa toggle, instead of in a separate row above the editor. File attachment previews remain as the only element above the textarea.
…[Aa ✕ | formatting] Passive state shows ingress buttons (mention, attach, emoji) followed by the Aa formatting toggle. Clicking Aa swaps to expanded mode: Aa toggle, ✕ close button, separator, then all formatting options. Ingress buttons are hidden while formatting is open. Matches the inline-expand design spec from the channel.
…/react - Aa toggle uses layoutId for smooth slide between positions - Ingress buttons (@ 📎 😊) fade out + scale down on expand - Formatting buttons slide in from left + fade in on expand - ✕ close button scales in alongside Aa - Reverse animation on collapse - Uses LayoutGroup + AnimatePresence (popLayout) matching existing ComposerAttachments pattern
…bels - Reset isEmojiPickerOpen when formatting is toggled on (prevents emoji picker remounting open on collapse) - Extract Aa toggle to local variable — single source of truth for the layoutId animation across both toolbar states - Add aria-label to mention button, attach button, and upload spinner
- Pull Aa toggle out of AnimatePresence so it never unmounts — layoutId handles the smooth position slide on its own - Remove React.Fragment wrappers — each branch is a single motion.div so AnimatePresence properly tracks enter/exit per element - popLayout mode pops exiting elements out of flow immediately, letting enter animations start at the same time - Ingress group uses order-[-1] to sit before Aa visually - No staggering, no sequencing — one fluid concurrent transition
…utocomplete insertion
TipTap's setContent() roundtrips through a markdown parser that strips
trailing whitespace from text nodes. insertContent(' ') and
preserveWhitespace: 'full' also normalise it away.
Use a raw ProseMirror transaction to insert a literal space text node
after setContent, bypassing TipTap's parser entirely. Cursor is placed
after the space via TextSelection so typing continues naturally.
Applied to both applyMentionInsert and applyChannelInsert.
Timeline images now render at max-w-lg (~512px) as clickable thumbnails with cursor-pointer and a subtle hover opacity transition. Clicking opens a scrimmed lightbox (bg-black/80) showing the full-res image at up to 90vh/90vw. Clicking the backdrop or pressing Escape closes the lightbox. Includes accessible Title and Description for Radix Dialog, and an explicit close button. Video rendering left unchanged.
Remove overflow-hidden from the toolbar's layout-animated container. The overflow clip (added in 50f951c to fix exit ghosting) was hiding the Aa toggle during intermediate layout frames — when popLayout pops the exiting group out of flow the container briefly shrinks, and overflow-hidden clips the layoutId-animated toggle mid-reposition. Also add layout="position" to the Aa wrapper so Framer Motion only animates its position, not size, avoiding measurement conflicts with the simultaneously-resizing parent. Exit animations (opacity → 0) still look correct without the clip since the fading elements are the intended visual.
Composer attachment thumbnail chips now open a full-view lightbox when clicked, reusing the same Radix DialogPrimitive pattern from the timeline markdown renderer (1ba0dd2). Videos open with native controls. The existing remove button (X) remains unaffected — the lightbox trigger wraps only the thumbnail content, not the remove button overlay. Also bumps the attachment row bottom margin from mb-1 to mb-2 for better spacing below the chips.
Replace the single boolean upload state with an uploadingCount that tracks how many files are currently in-flight. Each upload start increments the count; each completion or error decrements it. isUploading remains true while count > 0. handleDrop now processes ALL dropped files concurrently instead of only files[0]. Each valid file fires off its own upload in parallel. ComposerAttachments renders uploadingCount skeleton placeholders (falls back to 1 for backwards compat when count isn't provided).
…attachments Images now use w-full max-w-xl instead of fixed max-w-lg, so they flex to their container width (fixes overflow in the 380px thread panel). When a message contains multiple consecutive images, a custom remark plugin (remarkImageGallery) groups them into an image-gallery node that renders as a 2-column CSS grid. Single images remain unchanged.
Use a slot-based approach: reserve null slots in original order when uploads start, then fill each slot by index as uploads complete. pendingImeta is derived by filtering nulls, so attachments always appear in paste/drop order regardless of completion timing. Also: handlePaste now handles multiple media items from clipboard.
Extend the existing MentionHighlightExtension to also decorate #channel-name patterns with the same mention-highlight CSS class. - Add channelNames to extension storage alongside names - Build a second regex pattern for #channel matching (same lookbehind logic as @mention: must be at start of text or preceded by whitespace) - Update useRichTextEditor to accept channelNames prop and sync it to extension storage in the same useEffect as mentionNames - Pass channelLinks.knownChannelNames from MessageComposer to the hook
29 tests covering: - buildHighlightPatterns: empty inputs, mentions-only, channels-only, both, regex escaping of special characters - findHighlightMatches: start-of-text, after whitespace, embedded-in-word rejection, case insensitivity, multiple matches, longer-name priority, mixed @mention + #channel, empty text/patterns - shortHash: normal, minimum, empty, short inputs - Upload slot ordering: reserve/fill, out-of-order concurrent fills, removal by URL, padding edge case
…ent render The Aa toggle was outside AnimatePresence using layoutId to animate between positions. This caused intermittent disappearance due to WebKit GPU compositing + Framer Motion layout cache issues. Restructure: duplicate the Aa toggle inside both state groups (formatting and ingress) so AnimatePresence handles the crossfade. Removed: layoutId, LayoutGroup, overflow-hidden, will-change hack, order-[-1] CSS trick. Kept AnimatePresence mode="popLayout".
markdown.tsx changes moved to tho/timeline-media branch (PR #316). This PR now focuses solely on composer polish.
… change - Remove .worktree/chat-style/desktop/src-tauri from Cargo.toml exclude (local dev artifact, not part of this PR) - Revert SIDEBAR_KEYBOARD_SHORTCUT from 's' back to 'b' (unrelated to composer polish work)
…ts mid-upload removeAttachment now nulls out the slot instead of compacting the array, so in-flight uploads that captured their slot index at reservation time still write to the correct position. The public pendingImeta view already filters nulls via useMemo.
The getTextAndCursor function maps ProseMirror positions to plain-text offsets for the mention/channel autocomplete hooks. The block boundary handler had an empty body, causing cursor drift of 1 per paragraph boundary. Now increments offset by 1 for each inter-block newline.
8971130 to
825d9c1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Category: improvement
User Impact: The message composer now supports rich text formatting (bold, italic, lists, quotes, links) with an animated toolbar, inline @mention and #channel highlighting, concurrent file uploads with skeleton previews, and click-to-open image lightboxes for composer attachments.
Problem: The composer was a plain textarea — users couldn't format messages without manually typing Markdown syntax, had no visual feedback for @mentions or #channel references while typing, and file uploads were sequential with no progress indication. Viewing full-size attached images required opening them externally.
Solution: Replace the textarea with a TipTap-based rich text editor that renders formatting live as users type. The toolbar uses a two-state animated design (passive → expanded) to keep the default view clean while making formatting discoverable. @mention and #channel patterns are highlighted inline via a ProseMirror decoration plugin. File uploads now run concurrently with per-file skeleton placeholders that preserve insertion order via a slot-based system. Images in the composer open in a lightbox overlay when their thumbnails are clicked.
File changes
Cargo.toml
Added
.worktree/chat-style/desktop/src-taurito the workspace exclude list to prevent Cargo from picking up worktree build artifacts.desktop/package.json
Added TipTap editor dependencies (
@tiptap/core,@tiptap/react,@tiptap/starter-kit,@tiptap/extension-link,@tiptap/extension-placeholder,@tiptap/pm,tiptap-markdown), themotionanimation library, and@radix-ui/react-togglefor the formatting toolbar.desktop/pnpm-lock.yaml
Lockfile update reflecting the new TipTap, motion, and Radix Toggle dependencies.
desktop/src/features/channels/ui/ChannelPane.tsx
Removed the
isSendingguard from the composer-disabled check so the composer stays interactive while a message is in flight.desktop/src/features/messages/lib/useRichTextEditor.ts (new)
Core TipTap editor hook — configures StarterKit, Markdown serialization, link autodetection, placeholder, and custom keyboard shortcuts for list/blockquote exit behavior. Exposes
getMarkdown(),setContentWithTrailingSpace()(for autocomplete insertion),getTextAndCursor()(bridges existing mention/channel hooks), andinsertImageRef().desktop/src/features/messages/lib/mentionHighlightExtension.ts (new)
ProseMirror plugin that scans text nodes for
@DisplayNameand#channel-namepatterns and applies inline decorations with amention-highlightCSS class. Updates reactively when the known names list changes.desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs (new)
Unit tests for the mention and channel highlight regex patterns, covering exact matches, partial matches, and edge cases.
desktop/src/features/messages/lib/imageRefExtension.ts (new)
Custom TipTap inline atom node (
imageRef) that renders uploaded attachments as thumbnail chips in the editor. Serializes to![hash]markers that the composer resolves to fullMarkdown on send.desktop/src/features/messages/lib/useImageRefSuggestions.ts (new)
Suggestion/autocomplete hook for the image ref node — lets users reference previously uploaded attachments by typing a trigger character.
desktop/src/features/messages/lib/useMediaUpload.ts
Rewritten to support concurrent multi-file uploads. Uses a slot-based system (
reserveSlots/fillSlot) so files dropped or pasted together maintain their original order regardless of which upload finishes first. ExportsshortHash()andALLOWED_MEDIA_TYPESfor reuse.desktop/src/features/messages/lib/useMediaUpload.test.mjs (new)
Tests for upload slot ordering — verifies that concurrent uploads filling slots out-of-order still produce the correct final attachment sequence.
desktop/src/features/messages/ui/MessageComposer.tsx
Replaced the
<textarea>with TipTap<EditorContent>. Wires up the rich text editor hook, formatting state, emoji insertion (now via TipTap commands), drag-and-drop, paste handling, and send logic that resolvesimageRefnodes to Markdown before submission.desktop/src/features/messages/ui/MessageComposerToolbar.tsx
Two-state animated toolbar using
motion/react. Passive state shows[@ 📎 😊 Aa]; pressing Aa crossfades to expanded state[Aa ✕ | formatting buttons]. UsesAnimatePresence mode="popLayout"for simultaneous enter/exit animations.desktop/src/features/messages/ui/FormattingToolbar.tsx (new)
Formatting button row (bold, italic, strikethrough, code, link, bullet list, ordered list, blockquote). Each button reflects active state from TipTap editor state via
useEditorState.desktop/src/features/messages/ui/ComposerAttachments.tsx (new)
Thumbnail strip for uploaded attachments with animated enter/exit, per-file upload skeleton placeholders, click-to-open lightbox preview, and remove buttons.
desktop/src/features/messages/ui/ImageRefAutocomplete.tsx (new)
Autocomplete dropdown for image reference insertion in the editor.
desktop/src/features/messages/ui/ComposerMentionOverlay.tsx (deleted)
Removed — mention highlighting is now handled by the ProseMirror decoration plugin inline in the editor, replacing the old overlay approach.
desktop/src/shared/styles/globals.css
Added ~100 lines of TipTap composer styles — placeholder text, inline formatting (bold, italic, strike, code), block elements (blockquote, lists, code blocks, links, horizontal rules), and the
.mention-highlightdecoration class.desktop/src/shared/ui/sidebar.tsx
Changed the sidebar toggle keyboard shortcut from
btosto avoid conflicting with the bold formatting shortcut (⌘B) in the rich text editor.desktop/src/shared/ui/toggle.tsx (new)
Shared
Togglecomponent (Radix Toggle primitive + Tailwind variants) used by the formatting toolbar buttons.How to verify
**bold**or press ⌘B — text should render bold inline.@followed by a member name — the mention should highlight with a colored pill in the editor.#followed by a channel name — same highlight treatment.